feat: LocalStorage as a sparse override overlay#47
Closed
windischb wants to merge 5 commits into
Closed
Conversation
LocalStorage is now a writable, app-controlled layer for "overridable defaults": lower layers (files, env, ...) supply defaults and the application overrides individual values at runtime. Writes are sparse -- only the touched leaf is persisted; unset keys keep inheriting. - Abstractions: ILocalStorage<T> (typed facade) + ILocalStorageOverlay<T> (raw key-path) + OverrideEntry (BCL-only) - Sparse mutation with base-casing alignment, explicit-null vs reset, and empty-ancestor pruning; secret-typed members rejected (NotSupportedException) - ConfigManager.BuildBaseJson: lock-free prefix merge for base/provenance - IProviderServiceRegistration extended with resolve-time factory support - DescribeAsync provenance (base / effective / isOverridden) for UIs - File backend hardened (per-write temp + TOCTOU guard); store disposed via DI - 31 tests; runnable LocalStorageOverride example; guide + changelog No auto REST endpoints by design -- ILocalStorage<T> is DI-injectable so apps write their own endpoints (validation/normalization/logging). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This was referenced May 29, 2026
# Conflicts: # CHANGELOG.md # website/changelog.md
Server-side half of browser-encrypted secrets for LocalStorage: - ILocalStorageOverlay<T>.SetSecretEnvelopeAsync(keyPath, JsonNode) stores a pre-encrypted cocoar.secret envelope; rejects plaintext and the "***" mask. - ILocalStorage<T>.SetSecretAsync(selector, envelope) typed variant; the normal SetAsync still rejects secret members (no plaintext into the overlay). - OverlayPathResolver gains allowSecretMembers for the secret-envelope path. The envelope merges at the secret's key and decrypts on Secret<T>.Open() via the normal read path -- plaintext never reaches the server. E2E test (encrypt with a test cert -> SetSecretAsync -> recompute -> Open) confirmed the runtime decrypt path requires base64url-WITHOUT-padding for wk/iv/ct/tag (NOT standard base64) -- the byte-exact format the browser TS lib must emit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…et guard - SecretEnvelope<T> (Abstractions): JSON-bindable wire form of an encrypted secret, so an API request DTO can carry it. The phantom T couples it to the target Secret<T> at compile time. It does NOT inherit from Secret<T> (different role: ciphertext-to-store vs openable secret). Binary fields are base64url-without-padding. - ILocalStorage<T>.SetSecretAsync(Expression<Func<T, ISecret<TSecret>>> selector, SecretEnvelope<TSecret> envelope) -- selector target and envelope value type are matched by the compiler; one overload covers both Secret<TSecret> and ISecret<TSecret> members. - SetAsync now rejects any value that is, or contains anywhere in its graph, a secret -- preventing silent plaintext serialization / secret loss for whole-object writes. Verified base64url-without-padding is the required wire encoding (standard base64 throws in the runtime decrypt path) -- the exact format the @cocoar/secrets browser lib must emit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…endpoint) Adds a read-only way to publish the configured single-kid encryption PUBLIC key so external producers (browser, CLI, PowerShell, ...) can build cocoar.secret envelopes the server decrypts — without ever holding the private cert. This closes the missing server-side half of browser-encrypted secrets (the key was previously only reachable by handing out the .pfx). - Abstractions: ISecretEncryptionKeyProvider (GetCurrentKeys / GetCurrentKey), records SecretEncryptionPublicKey + SecretEncryptionKeySet, and SecretAlgorithms constants. SecretEnvelope<T>.Alg/Walg now reference the constants (value-identical, no wire change). - Secrets: CertificateInventory.TryExportPreferredPublicKey() exports the decryption-engine-preferred cert's SPKI (public key only, under the write lock, the X509Certificate2 never leaks, transient load failures degrade gracefully). Internal ISecretEncryptionKeyInfoProvider is composed in single-kid mode only; SecretEncryptionKeyProvider resolves it lazily per call so rotation is reflected. - DI: registers ISecretEncryptionKeyProvider only when a publishable key exists. - AspNetCore: MapSecretEncryptionKeys / MapSecretEncryptionKeyByKid / MapSecretEncryptionKeyEndpoints; each returns IEndpointConventionBuilder so .RequireAuthorization() chains. Default-open like the flag endpoints. Additive only: decryption, kid-folders, rotation, caching, the CLI and every existing public API are untouched. Multi-kid (per-tenant) publishing is deferred with the multi-tenancy effort — ApplyMultiKidMode is unchanged and folder mode publishes an empty set for now. Tests: round-trip (published SPKI -> encrypt with the public key only -> Secret<T>.Open() recovers the value), endpoint list/by-kid/404/empty-set/auth chaining, and JSON field-name pinning under a non-default naming policy. Full suite 661/661 green; Release build 0 errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Contributor
Author
|
Superseded by the |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Re-implements the LocalStorage provider as a sparse override overlay — a writable, application-controlled layer for overridable defaults — and builds the server side of browser-encrypted secrets on top of it. The normal sources (files, env, …) supply defaults; the application overrides individual values at runtime (including pre-encrypted secrets), and everything it does not touch keeps inheriting from the lower layers.
Supersedes #46 (the earlier full-object write model, which clobbered lower layers and could not express "only what I set overrides"). Built fresh off
develop.LocalStorage override overlay
Public API (
Cocoar.Configuration.Abstractions)ILocalStorage<T>— typed facade:SetAsync(x => x.A.B, v),ResetAsync,ClearAsync,ReadAsync,DescribeAsync,.OverlayILocalStorageOverlay<T>— rawJsonNodekey-path surface for dynamic pathsOverrideEntry— per-key provenance DTO (base / effective / isOverridden)Engine / DI plumbing
ConfigManager.BuildBaseJson— lock-free prefix merge (base = layers below the overlay) for casing alignment + provenance; deliberately avoids the recompute semaphore (reactive publish runs inside it → would deadlock a write-back subscriber)IProviderServiceRegistrationextended with resolve-time factory registration (BCL-only, no MS-DI dependency in the core package); both interfaces resolve to one shared singleton adapterSemantics
null(clobber); default-valued overrides persist; arrays replaced wholesaleLocalStorageOverrideexample, changelog, README/sidebar rowsBrowser-encrypted secrets (server side)
A client encrypts a secret with the server's public key and hands back only the ciphertext — the server never sees plaintext.
Write path
ILocalStorage<T>.SetSecretAsync(x => x.ApiKey, envelope)(typed) andILocalStorageOverlay<T>.SetSecretEnvelopeAsync(keyPath, JsonNode)(raw) accept a pre-encryptedcocoar.secretenvelope; plaintext /"***"is rejectedSecretEnvelope<T>wire type (Abstractions);SetAsyncrejects any value whose object graph contains a secretPublish the encryption public key
ISecretEncryptionKeyProvider(DI:GetCurrentKeys/GetCurrentKey(kid)) + AspNetCoreMapSecretEncryptionKeys/MapSecretEncryptionKeyByKid/MapSecretEncryptionKeyEndpointspublish the configured single-kid encryption public key as SPKI (base64url, no padding) at/.well-known/cocoar/encryption-keysX509Certificate2never leaves the inventory; resolved lazily so certificate rotation is reflectedDesign notes
ILocalStorage<T>is DI-injectable; the app exposes its own write endpoint (controller / minimal-API / SignalR / gRPC) with its own validation, auth and tenancy, callingSetSecretEnvelopeAsync. The encryption-key endpoint is the only shipped HTTP surface: GET-only, default-open, chain.RequireAuthorization()to secureDescribeAsyncin core, no[Obsolete]shim (built fresh), noSetManyAsyncfor nowVerification
Deferred / follow-ups (not in this PR)
WriteBatchAsync/ILocalStorage<(A,B)>)@cocoar/secrets, cross-language round-trip test, demo app, user-facing docs🤖 Generated with Claude Code